/*! * Copyright 2012, Chris Wanstrath * Released under the MIT License * https://github.com/defunkt/jquery-pjax */ (function($){ // When called on a container with a selector, fetches the href with // ajax into the container or with the data-pjax attribute on the link // itself. // // Tries to make sure the back button and ctrl+click work the way // you'd expect. // // Exported as $.fn.pjax // // Accepts a jQuery ajax options object that may include these // pjax specific options: // // // container - String selector for the element where to place the response body. // push - Whether to pushState the URL. Defaults to true (of course). // replace - Want to use replaceState instead? That's cool. // // For convenience the second parameter can be either the container or // the options object. // // Returns the jQuery object function fnPjax(selector, container, options) { options = optionsFor(container, options) return this.on('click.pjax', selector, function(event) { var opts = options if (!opts.container) { opts = $.extend({}, options) opts.container = $(this).attr('data-pjax') } handleClick(event, opts) }) } // Public: pjax on click handler // // Exported as $.pjax.click. // // event - "click" jQuery.Event // options - pjax options // // Examples // // $(document).on('click', 'a', $.pjax.click) // // is the same as // $(document).pjax('a') // // Returns nothing. function handleClick(event, container, options) { options = optionsFor(container, options) var link = event.currentTarget var $link = $(link) if (link.tagName.toUpperCase() !== 'A') throw "$.fn.pjax or $.pjax.click requires an anchor element" // Middle click, cmd click, and ctrl click should open // links in a new tab as normal. if ( event.which > 1 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey ) return // Ignore cross origin links if ( location.protocol !== link.protocol || location.hostname !== link.hostname ) return // Ignore case when a hash is being tacked on the current URL if ( link.href.indexOf('#') > -1 && stripHash(link) == stripHash(location) ) return // Ignore event with default prevented if (event.isDefaultPrevented()) return var defaults = { url: link.href, container: $link.attr('data-pjax'), target: link } var opts = $.extend({}, defaults, options) var clickEvent = $.Event('pjax:click') $link.trigger(clickEvent, [opts]) if (!clickEvent.isDefaultPrevented()) { pjax(opts) event.preventDefault() $link.trigger('pjax:clicked', [opts]) } } // Public: pjax on form submit handler // // Exported as $.pjax.submit // // event - "click" jQuery.Event // options - pjax options // // Examples // // $(document).on('submit', 'form', function(event) { // $.pjax.submit(event, '[data-pjax-container]') // }) // // Returns nothing. function handleSubmit(event, container, options) { options = optionsFor(container, options) var form = event.currentTarget var $form = $(form) if (form.tagName.toUpperCase() !== 'FORM') throw "$.pjax.submit requires a form element" var defaults = { type: ($form.attr('method') || 'GET').toUpperCase(), url: $form.attr('action'), container: $form.attr('data-pjax'), target: form } if (defaults.type !== 'GET' && window.FormData !== undefined) { defaults.data = new FormData(form) defaults.processData = false defaults.contentType = false } else { // Can't handle file uploads, exit if ($form.find(':file').length) { return } // Fallback to manually serializing the fields defaults.data = $form.serializeArray() } pjax($.extend({}, defaults, options)) event.preventDefault() } // Loads a URL with ajax, puts the response body inside a container, // then pushState()'s the loaded URL. // // Works just like $.ajax in that it accepts a jQuery ajax // settings object (with keys like url, type, data, etc). // // Accepts these extra keys: // // container - String selector for where to stick the response body. // push - Whether to pushState the URL. Defaults to true (of course). // replace - Want to use replaceState instead? That's cool. // // Use it just like $.ajax: // // var xhr = $.pjax({ url: this.href, container: '#main' }) // console.log( xhr.readyState ) // // Returns whatever $.ajax returns. function pjax(options) { options = $.extend(true, {}, $.ajaxSettings, pjax.defaults, options) if ($.isFunction(options.url)) { options.url = options.url() } var hash = parseURL(options.url).hash var containerType = $.type(options.container) if (containerType !== 'string') { throw "expected string value for 'container' option; got " + containerType } var context = options.context = $(options.container) if (!context.length) { throw "the container selector '" + options.container + "' did not match anything" } // We want the browser to maintain two separate internal caches: one // for pjax'd partial page loads and one for normal page loads. // Without adding this secret parameter, some browsers will often // confuse the two. if (!options.data) options.data = {} if ($.isArray(options.data)) { options.data.push({name: '_pjax', value: options.container}) } else { options.data._pjax = options.container } function fire(type, args, props) { if (!props) props = {} props.relatedTarget = options.target var event = $.Event(type, props) context.trigger(event, args) return !event.isDefaultPrevented() } var timeoutTimer options.beforeSend = function(xhr, settings) { // No timeout for non-GET requests // Its not safe to request the resource again with a fallback method. if (settings.type !== 'GET') { settings.timeout = 0 } xhr.setRequestHeader('X-PJAX', 'true') xhr.setRequestHeader('X-PJAX-Container', options.container) if (!fire('pjax:beforeSend', [xhr, settings])) return false if (settings.timeout > 0) { timeoutTimer = setTimeout(function() { if (fire('pjax:timeout', [xhr, options])) xhr.abort('timeout') }, settings.timeout) // Clear timeout setting so jquerys internal timeout isn't invoked settings.timeout = 0 } var url = parseURL(settings.url) if (hash) url.hash = hash options.requestUrl = stripInternalParams(url) } options.complete = function(xhr, textStatus) { if (timeoutTimer) clearTimeout(timeoutTimer) fire('pjax:complete', [xhr, textStatus, options]) fire('pjax:end', [xhr, options]) } options.error = function(xhr, textStatus, errorThrown) { var container = extractContainer("", xhr, options) var allowed = fire('pjax:error', [xhr, textStatus, errorThrown, options]) if (options.type == 'GET' && textStatus !== 'abort' && allowed) { locationReplace(container.url) } } options.success = function(data, status, xhr) { var previousState = pjax.state // If $.pjax.defaults.version is a function, invoke it first. // Otherwise it can be a static string. var currentVersion = typeof $.pjax.defaults.version === 'function' ? $.pjax.defaults.version() : $.pjax.defaults.version var latestVersion = xhr.getResponseHeader('X-PJAX-Version') var container = extractContainer(data, xhr, options) var url = parseURL(container.url) if (hash) { url.hash = hash container.url = url.href } // If there is a layout version mismatch, hard load the new url if (currentVersion && latestVersion && currentVersion !== latestVersion) { locationReplace(container.url) return } // If the new response is missing a body, hard load the page if (!container.contents) { locationReplace(container.url) return } pjax.state = { id: options.id || uniqueId(), url: container.url, title: container.title, container: options.container, fragment: options.fragment, timeout: options.timeout } if (options.push || options.replace) { window.history.replaceState(pjax.state, container.title, container.url) } // Only blur the focus if the focused element is within the container. var blurFocus = $.contains(context, document.activeElement) // Clear out any focused controls before inserting new page contents. if (blurFocus) { try { document.activeElement.blur() } catch (e) { /* ignore */ } } if (container.title) document.title = container.title fire('pjax:beforeReplace', [container.contents, options], { state: pjax.state, previousState: previousState }) context.html(container.contents) // FF bug: Won't autofocus fields that are inserted via JS. // This behavior is incorrect. So if theres no current focus, autofocus // the last field. // // http://www.w3.org/html/wg/drafts/html/master/forms.html var autofocusEl = context.find('input[autofocus], textarea[autofocus]').last()[0] if (autofocusEl && document.activeElement !== autofocusEl) { autofocusEl.focus() } executeScriptTags(container.scripts) var scrollTo = options.scrollTo // Ensure browser scrolls to the element referenced by the URL anchor if (hash) { var name = decodeURIComponent(hash.slice(1)) var target = document.getElementById(name) || document.getElementsByName(name)[0] if (target) scrollTo = $(target).offset().top } if (typeof scrollTo == 'number') $(window).scrollTop(scrollTo) fire('pjax:success', [data, status, xhr, options]) } // Initialize pjax.state for the initial page load. Assume we're // using the container and options of the link we're loading for the // back button to the initial page. This ensures good back button // behavior. if (!pjax.state) { pjax.state = { id: uniqueId(), url: window.location.href, title: document.title, container: options.container, fragment: options.fragment, timeout: options.timeout } window.history.replaceState(pjax.state, document.title) } // Cancel the current request if we're already pjaxing abortXHR(pjax.xhr) pjax.options = options var xhr = pjax.xhr = $.ajax(options) if (xhr.readyState > 0) { if (options.push && !options.replace) { // Cache current container element before replacing it cachePush(pjax.state.id, [options.container, cloneContents(context)]) window.history.pushState(null, "", options.requestUrl) } fire('pjax:start', [xhr, options]) fire('pjax:send', [xhr, options]) } return pjax.xhr } // Public: Reload current page with pjax. // // Returns whatever $.pjax returns. function pjaxReload(container, options) { var defaults = { url: window.location.href, push: false, replace: true, scrollTo: false } return pjax($.extend(defaults, optionsFor(container, options))) } // Internal: Hard replace current state with url. // // Work for around WebKit // https://bugs.webkit.org/show_bug.cgi?id=93506 // // Returns nothing. function locationReplace(url) { window.history.replaceState(null, "", pjax.state.url) window.location.replace(url) } var initialPop = true var initialURL = window.location.href var initialState = window.history.state // Initialize $.pjax.state if possible // Happens when reloading a page and coming forward from a different // session history. if (initialState && initialState.container) { pjax.state = initialState } // Non-webkit browsers don't fire an initial popstate event if ('state' in window.history) { initialPop = false } // popstate handler takes care of the back and forward buttons // // You probably shouldn't use pjax on pages with other pushState // stuff yet. function onPjaxPopstate(event) { // Hitting back or forward should override any pending PJAX request. if (!initialPop) { abortXHR(pjax.xhr) } var previousState = pjax.state var state = event.state var direction if (state && state.container) { // When coming forward from a separate history session, will get an // initial pop with a state we are already at. Skip reloading the current // page. if (initialPop && initialURL == state.url) return if (previousState) { // If popping back to the same state, just skip. // Could be clicking back from hashchange rather than a pushState. if (previousState.id === state.id) return // Since state IDs always increase, we can deduce the navigation direction direction = previousState.id < state.id ? 'forward' : 'back' } var cache = cacheMapping[state.id] || [] var containerSelector = cache[0] || state.container var container = $(containerSelector), contents = cache[1] if (container.length) { if (previousState) { // Cache current container before replacement and inform the // cache which direction the history shifted. cachePop(direction, previousState.id, [containerSelector, cloneContents(container)]) } var popstateEvent = $.Event('pjax:popstate', { state: state, direction: direction }) container.trigger(popstateEvent) var options = { id: state.id, url: state.url, container: containerSelector, push: false, fragment: state.fragment, timeout: state.timeout, scrollTo: false } if (contents) { container.trigger('pjax:start', [null, options]) pjax.state = state if (state.title) document.title = state.title var beforeReplaceEvent = $.Event('pjax:beforeReplace', { state: state, previousState: previousState }) container.trigger(beforeReplaceEvent, [contents, options]) container.html(contents) container.trigger('pjax:end', [null, options]) } else { pjax(options) } // Force reflow/relayout before the browser tries to restore the // scroll position. container[0].offsetHeight // eslint-disable-line no-unused-expressions } else { locationReplace(location.href) } } initialPop = false } // Fallback version of main pjax function for browsers that don't // support pushState. // // Returns nothing since it retriggers a hard form submission. function fallbackPjax(options) { var url = $.isFunction(options.url) ? options.url() : options.url, method = options.type ? options.type.toUpperCase() : 'GET' var form = $('